Skip to content

Add has_closure_tree_roots (plural) to allow has_many in related AR models #446

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: master
Choose a base branch
from

Conversation

sergioisidoro
Copy link

Having has_closure_tree_root is quite useful, when there is a root instance that collects all children.

However I have the use case where a related model has multiple roots. This is not a case of multi-parent hierarchy, but instead a related object having multiple roots. The example in the tests is one of them. Post has many comments, and those comments can have children. With has_closure_tree_root we would be forced to create a root comment to each post, from which all members would be decendents from to use the query helpers.

This allows a related model to have multiple associated roots, and have the same perfomance boost in the queries as with the has_closure_tree_root..

NOTE I was assisted by an AI agent while doing this work, and some of the code here is generated by a model. It has been reviewed and iterated by me.

@kbrock
Copy link
Contributor

kbrock commented May 22, 2025

This seems like a lot of change.

Have you tried either of these ways?: STI or ignoring the post in the comment model.

STI would have Comment as a child class of Post. Not sure that I like this, but some people like STI.

Treat the Comment as a tree form, but just have a belongs_to :post?
So you just bring back all comments arranged, via:

post.comments.roots
Comment.where(:post_id => post.id).roots

Besides roots, you can call any scope/method that works on Comment

@sergioisidoro
Copy link
Author

sergioisidoro commented May 23, 2025

This seems like a lot of change.

Well, the challenging thing for this change was tests. I would say 90% of this PR is setting up the test project + specs. Otherwise we're using almost the same code with a bit of changes. But because I don't want to break any backwards compatibility there was a bit of copy paste.

About STI, I would really like to avoid it at all cost. It has cause me a lot of pain the past.

About your other solution, I'm not sure if it will provide the same benefits of n+1 query avoidance. If I have 100 roots (eg. 100 root comments on a post), it will result in 100 queries for the children of each of the root nodes? Perhaps I'm missing something from the eager loading methods, like root_comment_including_tree

If anyone would like to help me add documentation on how to handle eager loading with multi root has_many, I'm happy to participate in whatever way I can.

@sergioisidoro
Copy link
Author

sergioisidoro commented May 23, 2025

To summarize -- There are 300+ lines but the core of the change are just these lines

          # For multiple roots, fetch all descendants at once to avoid N+1 queries
          root_ids = roots.map(&:id)
          klass = roots.first.class
          hierarchy_table = klass._ct.quoted_hierarchy_table_name

          # Get all descendants of all roots including the roots themselves
          # (generations 0 = self, > 0 = descendants)
          descendant_scope = klass.
            joins("INNER JOIN #{hierarchy_table} ON #{hierarchy_table}.descendant_id = #{klass.quoted_table_name}.#{klass.primary_key}").
            where("#{hierarchy_table}.ancestor_id IN (?)", root_ids).
            includes(*assoc_map).
            distinct

          descendant_scope

@kbrock
Copy link
Contributor

kbrock commented May 23, 2025

About STI, I would really like to avoid it at all cost. It has cause me a lot of pain the past.

lol - yes. My teammates no longer listen when I say "lets get rid of the STI in model X." Different people are in different places on that debate. Seems many of the users of ancestry rely upon STI in the darndest places. (sorry, ancestry is more my point of reference - just trying to help a little over here, too.)

I'm not sure if it will provide the same benefits of n+1 query avoidance

I think we agree that the perfect case requires 2 queries: Post and Comment. And the perfect case would be to state it as a single query on Post with the Comment query being driven by an includes(:comments) or closure tree specific scope/method stating that.

Wish it were easier to say to active record: "I've fetched these records because you didn't know how to fetch them for includes(). Please put them into these objects as if it were fetched using includes()

I was thinking:

posts = Post.take(10)
comments = Comment.where(:post_id => posts.map(&:id)).hash_tree

posts.each do |post|
  puts "#{post.id}: #{post.name}"
  print_comments(comments.keys.select { |comment| comment.post_id == post.id})
end

def print_comments(comments_hash, indent: 0)
  comments_hash.each do |comment, children|
    puts " " * indent + comment.text
    print_comments(children, indent: indent + 2) unless children.blank?
  end
end

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants